在多執行緒下,編譯器或 CPU 可能為了追求效能而重排指令順序,導致實際執行的結果與預期不同,因此需要「記憶體模型(memory model)」來規範不同執行緒間,對共享記憶體資料的操作方式。
在以下程式碼中,生產者執行緒 t1 跟消費者執行緒 t2 都取用了 data 跟 ready 變數,且 t1 執行了「寫入」改變兩變數的值:
#include <iostream>
#include <thread>
int data = 0;
bool ready = false;
void producer() {
data = 42;
ready = true;
}
void consumer() {
while (!ready) {
// busy wait
}
std::cout << "data = " << data << "\n";
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
}
因為編譯器優化、指令重排等原因,不能保證一定會照以下順序執行:
data 改成 42。ready 改成 true。ready 為 true。data = 42。反而可能出現:
data = 0(因 CPU 重排指令導致 data = 42 被延遲時)。ready 值只被讀取一次時)。或任何非預期狀況,Day 23 提到的「資料競爭」在這個範例中也會出現(因為有複數個執行緒同時操作相同的普通變數,且其中至少一個執行緒是做「寫入」)。
現在把變數 data 跟 ready 用 std::atomic 使其具備原子特性,另用 memory_order_relaxed 保證當下操作的原子性:
#include <atomic>
#include <iostream>
#include <thread>
std::atomic<int> data{0}; // 變數加上原子特性
std::atomic<bool> ready{false}; // 變數加上原子特性
void producer() { // 確保每個操作都具備原子特性,但不保證執行順序
data.store(42, std::memory_order_relaxed);
ready.store(true, std::memory_order_relaxed);
}
void consumer() {
while (!ready.load(std::memory_order_relaxed)) {
// busy wait
}
std::cout << "data = " << data.load(std::memory_order_relaxed) << "\n";
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
}
這樣雖然可確保每次的讀寫操作都具備原子特性(不會還沒完成完整的讀寫操作,變數就被其他執行緒搶走),但並沒有處理不同執行緒的順序問題。
也就是說,加上原子特性可確保不會產生資料競爭,但仍可能因為 CPU 為了提升效能、調整執行順序而發生以下狀況:
ready.store(true, std::memory_order_relaxed);,把 ready 從 false 改成 true。while (!ready.load(std::memory_order_relaxed)) 已發現 ready 是 true,因此直接顯示 data = 0。data.store(42, std::memory_order_relaxed);,把 data 從 0 改成 42。現在的目標是要確定 ready 被改成 true 之前,一定要執行完 data 改成 42 的操作,可以用 memory_order_release 搭配 memory_order_acquire 如下:
#include <atomic>
#include <iostream>
#include <thread>
std::atomic<int> data{0};
std::atomic<bool> ready{false};
void producer() {
data.store(42, std::memory_order_relaxed);
ready.store(true, std::memory_order_release);
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) {
// busy wait
}
std::cout << "data = " << data.load(std::memory_order_relaxed) << "\n";
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
}
這裡發生的關鍵變化是:
ready.store(true, std::memory_order_release); 表示寫在這個步驟前的所有操作,都不會跑到這行後面執行。ready.load(std::memory_order_acquire) 之後的所有操作,都不會跑到這行之前執行。等於是確立好了執行順序,一定是:
memory_order_release 之前的所有操作結果都已經寫入記憶體,確保後續操作可見。memory_order_release 所在的操作。memory_order_acquire 所在的操作。memory_order_acquire 之後的所有操作。如此就能確保消費者的 while 迴圈看到當中的值是 true 時,data 變數的值必已經變為 42。
這種「後發生的事情,一定能看清前面發生事情結果」的機制,稱為「先行發生(happened-before 或 happens-before)」。
剛剛的 relase/acquire 已經可以完美解決執行順序不如預期的問題,然而,如果有多個操作要安排好時序,可以用更強大的 memory_order_seq_cst,寫起來更為簡潔:
#include <atomic>
#include <iostream>
#include <thread>
std::atomic<int> data{0};
std::atomic<bool> ready{false};
void producer() {
data.store(42, std::memory_order_seq_cst);
ready.store(true, std::memory_order_seq_cst);
}
void consumer() {
while (!ready.load(std::memory_order_seq_cst)) {
// busy wait
}
std::cout << "data = " << data.load(std::memory_order_seq_cst) << "\n";
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
}
這樣可以確保一定是這樣的執行順序:
data.store(42, std::memory_order_seq_cst);,並確保寫入結果可被後續操作看到。ready.store(true, std::memory_order_seq_cst);,並確保變更結果可被後續操作看到。while (!ready.load(std::memory_order_seq_cst)),讀到第 2. 步改成的 true。std::cout << "data = " << data.load(std::memory_order_seq_cst) << "\n";,此時 data 的值必為第 1. 步寫入後的結果 42。雖然不用手動處理誰先誰後,但這也代表所有有助於效能優化的操作都會被清空,確保嚴格遵守預期的執行順序,對於硬體來說的負擔較大,在目前只有兩個執行緒要協調的情境下,可說是用大砲打小鳥。